/*
 * Copyright 2009-2010 Nanjing RedOrange ltd (http://www.red-orange.cn)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package redora.generator;

import org.apache.commons.io.IOUtils;
import org.dom4j.Element;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import redora.util.DefaultErrorHandler;
import redora.util.SchemaValidator;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.logging.Logger;

import static java.io.File.separator;
import static java.util.logging.Level.SEVERE;
import static javax.xml.xpath.XPathConstants.NODESET;
import static org.w3c.dom.Node.ELEMENT_NODE;
import static redora.generator.Template.Input.*;
import static redora.generator.Template.Input.Enum;
import static redora.generator.XMLUtil.*;

/**
 * Retrieves all available models from the file system in this project and
 * generated all the sources and resources with the available templates. The
 * maven plugin will use the ModelProcessor directly, so just add the maven
 * plugin to your project's plugins.
 * 
 * @author Nanjing RedOrange (http://www.red-orange.cn)
 */
public class ModelProcessor {

    /**
     * Schema file for checking if the model document is correct.
     */
    public static final String MODEL_SCHEMA = "/model.xsd";
    /**
     * Schema file for checking if the application document is correct.
     */
    public static final String APPLICATION_SCHEMA = "/application.xsd";
    /**
     * Schema file for checking if the include document is correct.
     */
    public static final String INCLUDE_SCHEMA = "/include.xsd";

    final FileLocations where;
    final GeneratorTemplate templateTemplate;
    final TemplateProcessor templateProcessor;
    final ModelFileFinder finder;
    final String basePackage;
    final String defaultLanguage;
    final String artifactId;
    final Document allModels;
    Document model;
    final Normalizer normalizer;

    // Useful lists
    final Set<String> sortedModels = new HashSet<String>();
    final Map<String, Document> models = new HashMap<String, Document>();

    /**
     * @param where (Mandatory)
     * @param basePackage (Mandatory) From Maven pom, like 'com.company'
     * @param artifactId (Mandatory) Project name, from maven pom
     * @param defaultLanguage (Mandatory) From Maven pom, from there it defaults to 'en'.
     * @throws ModelGenerationException Passing on
     */
    public ModelProcessor(@NotNull FileLocations where, @NotNull String basePackage
                        , @NotNull String artifactId, @NotNull String defaultLanguage)
                throws ModelGenerationException {
        this.where = where;
        this.basePackage = basePackage;
        this.defaultLanguage = defaultLanguage;
        this.artifactId = artifactId;
        allModels = newDocument(basePackage, "all");
        finder = new ModelFileFinder(where);
        templateTemplate = new GeneratorTemplate(where.resourceDir);
        normalizer = new Normalizer(basePackage);
        this.templateProcessor = new TemplateProcessor(where);
    }

    /**
     * Generate sources for project
     * @throws ModelGenerationException The only exception you could get
     */
    public void generate() throws ModelGenerationException {

        validateAndLoadModels();
        sortedModels();
        int sequence = 0;
        //Normalize all models (mainly add redundancy so the templates are easier to make), and fill AllModels
        for (Map.Entry<String, Document> entry : models.entrySet()) {
            model = entry.getValue(); //keep track of this so you can make nicer exception messages
            normalizer.normalize(model, entry.getKey(), sortedModels, sequence++);
            allModels.getFirstChild()
                    .appendChild(allModels.importNode(model.getFirstChild(), true));
        }
        model = null;

        upgradeFiles();
        loadApplication();
        loadLanguages();
        loadSchemas();
        globalEnums();

        dumpModelToLocalFile();
        dumpAllModelsToLocalFile();

        //Run the per model generation stuff
        for (Map.Entry<String, Document> entry : models.entrySet()) {
            model = entry.getValue();
            for (Template tpl : templateTemplate.byInput(Model)) {
                if (tpl.ignoreProjects == null || !tpl.ignoreProjects.contains(artifactId)) {
                    templateProcessor.process(tpl, model, basePackage + "." + tpl.packageSuffix,
                            tpl.getDestinationFileName(entry.getKey(), null, null), null, artifactId);
                } else {
                    System.out.println("Ignoring " + tpl.name + " for " + artifactId);
                }
            }
            model = null;
        }
        //Run the AllModels
        for (Template tpl : templateTemplate.byInput(AllModels)) {
            if (tpl.ignoreProjects == null || !tpl.ignoreProjects.contains(artifactId)) {
                templateProcessor.process(tpl, allModels, basePackage + "." + tpl.packageSuffix,
                        tpl.getDestinationFileName(null, null, null), null, artifactId);
            } else {
                System.out.println("Ignoring " + tpl.name + " for " + artifactId);
            }
        }

        for (Map.Entry<String, Document> entry : enumerations(allModels, basePackage).entrySet()) {
            System.out.println("Processing enum " + entry.getKey());
            for (Template tpl : templateTemplate.byInput(Enum)) {
                if (tpl.ignoreProjects == null || !tpl.ignoreProjects.contains(artifactId)) {
                    templateProcessor.process(tpl, entry.getValue(), basePackage + "."
                            + tpl.packageSuffix, tpl.getDestinationFileName(null, null, entry
                            .getKey()), null, artifactId);
                } else {
                    System.out.println("Ignoring " + tpl.name + " for " + artifactId);
                }
            }
        }

        for (String language : definedLanguages(allModels)) {
            Map<String, String> langParam = new HashMap<String, String>();
            langParam.put("language", language);
            System.out.println("Processing language " + language);
            for (Template tpl : templateTemplate.byInput(Language)) {
                if (tpl.ignoreProjects == null || !tpl.ignoreProjects.contains(artifactId)) {
                    templateProcessor.process(tpl, allModels,
                            basePackage + "." + tpl.packageSuffix, tpl.getDestinationFileName(null,
                                    language, null), langParam, artifactId);
                } else {
                    System.out.println("Ignoring " + tpl.name + " for " + artifactId);
                }
            }
        }
    }

    /**
     * Adds if exists the application.xml to allModels
     * 
     * @throws ModelGenerationException Usually if templates.xml isn't valid XML
     */
    private void loadApplication() throws ModelGenerationException {
        File application = new File(where.applicationFile);
        if (application.exists()) {
            Document doc;
            try {
                doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(application);
            } catch (Exception e) {
                throw new ModelGenerationException(
                        "The application file is not parseable, probably not valid XML.", e);
            }
            allModels.getFirstChild().appendChild(allModels.importNode(doc.getFirstChild(), true));
        }
    }

    /**
     * Loads model from file system. Does the necessary preparation: adding
     * includes and normalizes it. The model is added to allModels.
     * 
     * @param modelFile
     *            (Mandatory)
     * @return The ready to use Document of given modelFile
     * @throws ModelGenerationException
     *             Impossible, when the model is not valid XML.
     */
    @NotNull
    Document loadModel(@NotNull String modelFile) throws ModelGenerationException {
        Document retVal;
        try {
            retVal = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(
                    new File(where.modelDir, modelFile));
        } catch (Exception e) {
            throw new ModelGenerationException("The model " + modelFile
                    + " is not parseable, probably not valid XML.", e);
        }

        loadIncludes(retVal);

        return retVal;
    }

    /**
     * Check if the model files comply with model.xsd. And check if the include files comply with include.xsd.
     * Check if the application file complies with application.xsd.
     * <br>
     * Load the model into a Document and add it to {@link #models}. While loading replace any include tags with
     * their corresponding include document.
     *
     * @throws ModelGenerationException When a model or include file does not comply, this exception is thrown.
     */
    void validateAndLoadModels() throws ModelGenerationException {
        // Check the application file, if there is one
        File applicationFile = new File(where.applicationFile);
        if (applicationFile.exists()) {
            if (schemaValidation(APPLICATION_SCHEMA, applicationFile)) {
                throw new ModelGenerationException("Model file " + applicationFile
                        + " failed the schema validation, i will stop now."
                        + ModelGenerationException.printModel(where, applicationFile.getName()));
            }
        }
        // Check the model files and add it to models list
        for (String modelFile : finder.findModelFiles()) {
            if (schemaValidation(MODEL_SCHEMA, new File(where.modelDir, modelFile))) {
                throw new ModelGenerationException("Model file " + modelFile
                        + " failed the schema validation, i will stop now."
                        + ModelGenerationException.printModel(where, modelFile));
            }
            models.put(modelFile.replace(".xml", ""), loadModel(modelFile));
        }
        System.out.println("All model files were checked as valid against model.xsd");

        for (String includeFile : finder.findIncludeFiles()) {
            if (schemaValidation(INCLUDE_SCHEMA, new File(where.includeDir, includeFile))) {
                throw new ModelGenerationException("Include file " + includeFile
                        + " failed the schema validation, i will stop now."
                        + ModelGenerationException.printInclude(where, includeFile));
            }
        }
        if (!finder.findIncludeFiles().isEmpty()) {
            System.out.println("All include files were checked as valid against include.xsd");
        }
    }

    /**
     * Validate the xml file according to schema file MODEL_SCHEMA
     * 
     * @param schema
     *            (Mandatory) Name of the schema file, like 'model.xsd'.
     * @param testFile
     *            (Mandatory) File you want to validate
     * @return True if the XML file does not match the XSD.
     * @throws ModelGenerationException Wrapping IO and XML Exceptions
     */
    @SuppressWarnings("unchecked")
    public boolean schemaValidation(@NotNull String schema, @NotNull File testFile) throws ModelGenerationException {
        DefaultErrorHandler errorHandler = new DefaultErrorHandler();

        SchemaValidator validator;
        try {
            if (schema.equalsIgnoreCase(INCLUDE_SCHEMA)) {
                String include = IOUtils.toString(ModelProcessor.class
                        .getResourceAsStream(INCLUDE_SCHEMA));
                String model = IOUtils.toString(ModelProcessor.class
                        .getResourceAsStream(MODEL_SCHEMA));
                InputStream includeStream = IOUtils.toInputStream(include.replace(
                        "<xs:include schemaLocation=\"model.xsd\" />", model.substring(model
                                .indexOf("<xs:complexType name=\"attributesType\">"), model
                                .lastIndexOf("</xs:schema>"))));
                validator = new SchemaValidator(includeStream);
            } else {
                validator = new SchemaValidator(ModelProcessor.class
                        .getResourceAsStream(MODEL_SCHEMA));
            }
        } catch (SAXException e) {
            throw new ModelGenerationException("Failed to prepare validating file "
                    + testFile.getAbsolutePath(), e);
        } catch (IOException e) {
            throw new ModelGenerationException("Failed to prepare validating file "
                    + testFile.getAbsolutePath(), e);
        }
        try {
            validator.validate(testFile, errorHandler);
        } catch (SAXException e) {
            throw new ModelGenerationException("Model file " + testFile.getName()
                    + " did not test nice. "
                    + ModelGenerationException.printModel(where, testFile.getName()), e);
        } catch (IOException ex) {
            Logger.getLogger(ModelProcessor.class.getName()).log(SEVERE, null, ex);
        }

        if (errorHandler.getErrors().hasContent()) {
            System.out.println("XML file:" + testFile.getAbsolutePath()
                    + " failed to do checking according to XSD file:");
            Iterator<Element> ei = errorHandler.getErrors().elementIterator();
            while (ei.hasNext()) {
                Element el = ei.next();
                System.out.println(el.getStringValue());
            }

            return true;
        }

        return false;
    }

    /**
     * Search the model for include tags. Then replace these tags with their
     * corresponding include files.
     * 
     * @param model
     *            (Mandatory) The model document
     * @exception ModelGenerationException Wrapping XML exceptions
     */
    void loadIncludes(@NotNull Document model) throws ModelGenerationException {
        NodeList includes; //all the includes in the model
        try {
            includes = (NodeList) XPathFactory.newInstance().newXPath().evaluate("//include",
                    model, NODESET);
        } catch (XPathExpressionException e) {
            throw new ModelGenerationException("Failed in searching include", e);
        }
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

        // All includes are different because attributes can't be the same in a
        // model
        for (int i = 0; i < includes.getLength(); i++) {
            Node includeTag = includes.item(i);
            String includeFileName = where.includeDir + separator
                    + includeTag.getAttributes().getNamedItem("name").getNodeValue() + ".xml";
            System.out.println("Including " + includeFileName);
            Document includeDoc;
            try {
                includeDoc = factory.newDocumentBuilder().parse(new File(includeFileName));
            } catch (SAXException e) {
                throw new ModelGenerationException("I failed to create a DOM of this include "
                        + includeFileName, e);
            } catch (IOException e) {
                throw new ModelGenerationException("I failed to find this include file: "
                        + includeFileName, e);
            } catch (ParserConfigurationException e) {
                throw new ModelGenerationException("I failed to create a DOM of this include "
                        + includeFileName, e);
            }
            //Insert the attributes one by one in the model. Usually an include is just one attribute
            //but include.xsd allows more attributes.
            //The includes should be inserted on the same position as the include tag.
            for (int j = 0; j < includeDoc.getDocumentElement().getChildNodes().getLength(); j++) {
                Node insert = includeDoc.getFirstChild().getChildNodes().item(j);
                if (insert.getNodeType() == ELEMENT_NODE) {
                    //Add any attributes from the include tag
                    for (int h = 0; h < includeTag.getChildNodes().getLength(); h++) {
                        Node attributeNode = includeTag.getChildNodes().item(h);
                        if ("attribute".equals(attributeNode.getNodeName())) {
                            attribute(insert
                                    , attributeNode.getAttributes().getNamedItem("name").getNodeValue()
                                    , attributeNode.getAttributes().getNamedItem("value").getNodeValue());
                        }
                    }
                    model.getElementsByTagName("attributes").item(0).insertBefore(
                            model.importNode(insert, true), includes.item(i));
                    model.normalizeDocument();
                }
            }
            model.getElementsByTagName("attributes").item(0).removeChild(includes.item(i));
        }
        model.normalize();
    }

    /**
     * Finds all different language=... in allModels and adds it as language
     * tags in allModels. Adds the defaultLanguage tag
     * 
     * @throws ModelGenerationException Passing on
     */
    void loadLanguages() throws ModelGenerationException {
        allModels.getFirstChild().appendChild(
                allModels.importNode(newDocument(null, "languages").getFirstChild(), true));
        Node upgradeDoc = allModels.getElementsByTagName("languages").item(0);
        attribute(upgradeDoc, "defaultLanguage", defaultLanguage);
        for (String language : definedLanguages(allModels)) {
            Node tagNode = upgradeDoc.getOwnerDocument().createElement("language");
            tagNode.setTextContent(language);
            upgradeDoc.appendChild(tagNode);
        }
        allModels.normalize();
    }

    /**
     * Finds all different language=... in allModels and adds it as language
     * tags in allModels. Adds the defaultLanguage tag
     *
     * @throws ModelGenerationException Passing on
     */
    void loadSchemas() throws ModelGenerationException {
        allModels.getFirstChild().appendChild(
                allModels.importNode(newDocument(null, "schemas").getFirstChild(), true));
        Set<String> schemas = new HashSet<String>();
        Node upgradeDoc = allModels.getElementsByTagName("languages").item(0);
        XPath xpath = XPathFactory.newInstance().newXPath();
        NodeList schemaNodes;
        try {
            schemaNodes = (NodeList) xpath.evaluate("//schema", allModels, NODESET);
        } catch (XPathExpressionException e) {
            throw new ModelGenerationException("Filter on schema failed", e);
        }
        if (schemaNodes != null)
            for (int i = 0; i < schemaNodes.getLength(); i++)
                schemas.add(schemaNodes.item(i).getNodeValue());

        for (String schema : schemas) {
            Node tagNode = upgradeDoc.getOwnerDocument().createElement("schema");
            tagNode.setTextContent(schema);
            upgradeDoc.appendChild(tagNode);
        }
        allModels.normalize();
    }


    /**
     * Make a list of all the models that have the sorted tag. Add it to the {@link #sortedModels} Set.
     * @throws ModelGenerationException Wrapping XPath exceptions
     */
    void sortedModels() throws ModelGenerationException {
        for (Map.Entry<String, Document> entry : models.entrySet()) {
            model = entry.getValue();
            try {
                if (isSortable(model)) {
                    sortedModels.add(entry.getKey());
                }
            } catch (XPathExpressionException e) {
                throw new ModelGenerationException("Can't define sorted for " + entry.getKey(), e);
            }
        }
        model = null;
    }

    /**
     * Makes a list of all the global enums, adds them to allModels in /all/globals.
     * @throws ModelGenerationException Wrapping XPath exceptions
     */
    private void globalEnums() throws ModelGenerationException {
        XPath xpath = XPathFactory.newInstance().newXPath();
        NodeList globals;
        try {
            globals = (NodeList)xpath.evaluate("//attributes/enum[@scope='global']", allModels, NODESET);
        } catch (XPathExpressionException e) {
            throw new ModelGenerationException("It seems to be a problem to create the globals list", e);
        }

        HashSet<String> unique = new HashSet<String>();
        Document globalDoc = newDocument(null, "globals");
        for (int i = 0; i < globals.getLength(); i++) {
            if (unique.add(globals.item(i).getAttributes().getNamedItem("class").getNodeValue())) {
                globalDoc.getFirstChild().appendChild(globalDoc.importNode(globals.item(i), true));
            }
        }
        allModels.getFirstChild().appendChild(
                allModels.importNode(globalDoc.getFirstChild(), true));
    }

    /**
     * Finds upgrade files in the upgrade directory and adds them to the
     * allModels document.
     * @throws ModelGenerationException Passing on
     */
    private void upgradeFiles() throws ModelGenerationException {
        Document upgradeDoc = newDocument(null, "upgrades");

        for (String file : finder.upgradeFiles()) {
            Node tagNode = upgradeDoc.createElement("upgrade");
            tagNode.setTextContent(file);
            upgradeDoc.getFirstChild().appendChild(tagNode);
        }
        allModels.getFirstChild().appendChild(
                allModels.importNode(upgradeDoc.getFirstChild(), true));
    }

    @NotNull
    public String dump() {
        String retVal = "Dumping model contents.\r\n";
        if (model != null) {
            retVal += "Model:\r\n" + asString(model) + "\r\n";
        }
        retVal += "Model:\r\n" + asString(allModels) + "\r\n";
        return retVal;
    }

    public void dumpModelToLocalFile() throws ModelGenerationException {
        for (Map.Entry<String, Document> entry : models.entrySet()) {
            try {
                TransformerFactory tFactory = TransformerFactory.newInstance();
                Transformer transformer = tFactory.newTransformer();

                transformer.setOutputProperty("encoding", "utf-8");
                DOMSource source = new DOMSource(entry.getValue());

                File modelFile = new File(finder.modelFiles(entry.getKey(), artifactId));
                StreamResult result = new StreamResult(modelFile);

                transformer.transform(source, result);
            } catch (TransformerException e) {
                throw new ModelGenerationException("Can't dumping model for " + entry.getKey(), e);
            }
        }
    }

    public void dumpAllModelsToLocalFile() throws ModelGenerationException {
        try {
            TransformerFactory tFactory = TransformerFactory.newInstance();
            Transformer transformer = tFactory.newTransformer();

            transformer.setOutputProperty("encoding", "utf-8");
            DOMSource source = new DOMSource(allModels);

            File allModelFile = new File(finder.allModelFiles(artifactId));
            StreamResult result = new StreamResult(allModelFile);
            transformer.transform(source, result);
        } catch (TransformerException e) {
            throw new ModelGenerationException("Can't dump allmodels", e);
        }
    }
}
